Add support for hosting MCP servers in ECS#55
Conversation
…ing a BBS credential
Reusable workflows do not inherit secrets automatically — the caller must pass secrets: inherit. Both docker-publish-wallet.yml and docker-publish.yml were calling the reusable workflow without this, causing DOCKER_HUB_USERNAME and DOCKER_HUB_ACCESS_TOKEN to arrive as empty strings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds extractBearerToken, verifyJWT (ES256), deriveWalletKey, and resolveAuthContext to a new @truvera/mcp-shared/auth export path. Pins jose at 6.2.3 for JWT verification. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ServerConfig gains optional toolHandlerFactory for per-session handlers
- bootstrapMCPServer passes AuthContext into createServer; stdio uses {mode:'none'}
- startHTTPTransport resolves auth on initialize requests, rejects with 401/403
on AuthError, and awaits the now-async serverFactory(context)
- AuthConfig/AuthContext re-exported from server/types for consumer convenience
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- scripts/generate-keypair.js: generates ES256 keypair, prints private key (for Secrets Manager) and public key (for ECS MCP_JWT_PUBLIC_KEY) - scripts/mint-jwt.js: mints a tenant JWT from a private key held in AWS Secrets Manager; supports --profile for dev/test/prod AWS profiles, --expires-in for configurable token lifetime - admin:generate-keypair and admin:mint-jwt npm script aliases - README: documents MCP_JWT_PUBLIC_KEY / MCP_MASTER_SECRET env vars and full admin workflow for one-time setup and tenant provisioning Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- WalletClientPool: lazy-initialises one WalletClient per SQLite path, promise-based deduplication for concurrent session starts, retryable on init failure, shutdownAll() for graceful drain - index.ts: replaces static single-wallet init with toolHandlerFactory; per-session factory resolves tenant db path from AuthContext (jwt → /data/wallets/<sub>, none → WALLET_DB_PATH), creates per-tenant DIDClient/CredentialClient/MessageClient/DelegationClient/AgentCardClient, tracks MessageClients for clean SIGTERM/SIGINT shutdown - MCP_AUTH_MODE=jwt requires MCP_JWT_PUBLIC_KEY; defaults to none for local dev with no config changes needed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- mcp-shared/src/auth/index.test.ts: 21 tests covering extractBearerToken, verifyJWT (including expired/wrong-key/no-sub/bad-pem), deriveWalletKey, and resolveAuthContext across all three auth modes - wallet-server/src/tests/unit/wallet-client-pool.test.ts: 8 tests covering instance caching, concurrent-init deduplication, constructor arg forwarding, retry-after-failure, shutdownAll drain, pool clearing, and partial-failure resilience in shutdown - wallet-server package.json: adds wallet-client-pool test to npm test script Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
In HTTP mode the caller's bearer token IS their Truvera API key. A per-session TruveraClient is created from context.apiKey inside toolHandlerFactory, so TRUVERA_API_KEY is no longer required at startup for HTTP deployments. stdio mode is unchanged: TRUVERA_API_KEY still required from env for local dev / CLI usage. AP2 schema initialisation remains a one-time startup fetch; only the per-session client wiring (AP2Client, OpenIdClient) is done inside the factory. Tests: 4 new unit tests for TruveraClient per-session key wiring confirm the bearer token is forwarded correctly and that distinct sessions use distinct keys. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Creates a self-contained Terraform configuration for deploying both MCP
services to a fresh AWS account (one account per environment).
Infrastructure created:
- ECS Fargate cluster (truvera-mcp-{env}) with container insights
- ALB with HTTPS listener (TLS 1.3), HTTP→HTTPS redirect, host-based
routing rules routing each hostname to its service
- truvera-api-mcp: stateless, no volumes, passthrough auth — bearer
token forwarded directly to the Truvera REST API
- wallet-mcp: EFS-backed SQLite, JWT auth, desired_count=1 enforced
by service config and documented constraint (SQLite single-writer)
- EFS file system with per-AZ mount targets and an access point that
pins the container to /wallets with uid/gid 1000
- Separate security groups per service (ALB, truvera-api task,
wallet-server task, EFS) for clean isolation
- IAM execution role (shared) with Secrets Manager read for wallet
secret injection at startup; per-service task roles
- CloudWatch log groups (30-day retention)
MCP_JWT_PUBLIC_KEY and WALLET_MASTER_KEY are injected from a
Secrets Manager secret at task startup — the secret must be created
manually before first apply (see terraform.tfvars.example).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes the assumption of a pre-existing VPC. Terraform now provisions all networking resources needed for a fresh AWS account: - VPC (10.0.0.0/16 by default, overridable via vpc_cidr) - Internet Gateway - 2 public subnets across two AZs (cidrsubnet-derived, one per AZ) - Public route table with 0.0.0.0/0 → IGW - Route table associations All references to var.vpc_id / var.subnet_ids replaced with aws_vpc.main.id and aws_subnet.public[*].id respectively. Outputs now include vpc_id and public_subnet_ids. terraform.tfvars.example updated to reflect that networking is fully managed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- backend.tf: S3 remote state with DynamoDB locking; includes the bootstrap commands to create the bucket and lock table (once per account before terraform init) - environments/staging.tfvars, prod.tfvars, test.tfvars: one file per environment with appropriate hostnames and cheqd_network; ARN placeholders to fill in after ACM cert and Secrets Manager secret are created - .gitignore: environments/ is committed (config, not secrets); terraform.tfvars (local overrides) stays ignored Workflow: AWS_PROFILE=<env> terraform init, then terraform apply -var-file=environments/<env>.tfvars Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
workflow_dispatch runs now tag the image with the sanitized branch name (e.g. DCKA-5551-hmac-auth) instead of the sequential run number, so staging.tfvars can reference a stable, meaningful tag while iterating on a branch. An explicit tag input overrides the default. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ode 22+
esm v3.2.25 uses Function.prototype.apply on an internal that Node 22+
no longer exposes, crashing the wallet-server on startup with
"Function.prototype.apply was called on undefined".
The crash path: dist/index.js (ESM) → wallet-sdk-wasm/lib/services/blockchain
/service.js (CJS) → jsonld-signatures/node_modules/jsonld → documentLoaders/
node.js → require('@digitalbazaar/http-client') → index.js: require = require
('esm')(module) → crash.
Fix: add http-client-stub.cjs — a self-contained CJS module that implements
the httpClient.get/post surface using native Node 22+ fetch — and register it
as a module-alias for @digitalbazaar/http-client, so module-alias intercepts
all require() calls to the package (including from deeply nested CJS modules)
before they reach the broken esm wrapper.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
…f sensitive information' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR adds an AWS ECS (Fargate) hosting model for the MCP servers and introduces request-level authentication support in the shared MCP HTTP transport so the services can run as multi-tenant (wallet via JWT) or per-request credential passthrough (truvera-api via API key).
Changes:
- Added Terraform configuration to deploy both MCP servers behind a single ALB, with EFS-backed persistence for the wallet server.
- Introduced
@truvera/mcp-shared/authand updated the shared HTTP transport + server bootstrap to resolve auth context per MCP session. - Updated
truvera-apiandwallet-serverto use per-session handler factories wired to the resolved auth context; expanded docs for hosted vs self-hosted usage.
Reviewed changes
Copilot reviewed 51 out of 54 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| WALLET_MCP_PLAN.md | Updates wallet server roadmap/status and documents JWT multi-tenant mode. |
| README.md | Adds hosted vs self-hosted guidance and documents per-client vs shared-key auth. |
| docs/claude-desktop-setup.md | Adds hosted connection instructions for Claude Desktop (API key + wallet token). |
| packages/mcp-shared/src/transport/http/index.ts | Resolves auth per initialize request and passes AuthContext into server factory. |
| packages/mcp-shared/src/server/types.ts | Adds auth config/context types + per-session handler factory option. |
| packages/mcp-shared/src/server/bootstrap.ts | Supports per-session handler factories and forwards auth config to HTTP transport. |
| packages/mcp-shared/src/auth/index.ts | Implements auth resolution (none, jwt, passthrough) + wallet-key derivation helper. |
| packages/mcp-shared/src/auth/index.test.ts | Adds unit tests covering auth parsing/verification and key derivation behavior. |
| packages/mcp-shared/README.md | Documents bootstrap/auth features and intended auth mode usage by apps. |
| packages/mcp-shared/package.json | Exposes ./auth entrypoint and adds jose dependency. |
| package-lock.json | Locks new dependencies (jose, AWS SDK clients for wallet admin scripts, etc.). |
| apps/wallet-server/src/index.ts | Switches to per-session handler factory with JWT auth + wallet pooling. |
| apps/wallet-server/src/wallet-client-pool.ts | Adds per-tenant WalletClient caching/deduplication layer. |
| apps/wallet-server/src/tests/unit/wallet-client-pool.test.ts | Adds unit coverage for wallet client pooling/deduplication/shutdown behavior. |
| apps/wallet-server/src/tests/integration/localstorage-shim.test.ts | Adjusts integration test import path for wallet-sdk storage shim behavior. |
| apps/wallet-server/src/build-info.ts | Bumps build metadata. |
| apps/wallet-server/scripts/mint-jwt.js | Adds admin script to mint tenant JWTs using Secrets Manager private key. |
| apps/wallet-server/scripts/generate-keypair.js | Adds keypair generation script for ES256 JWT auth. |
| apps/wallet-server/register-aliases.cjs | Extends module-alias wiring and adds stub for @digitalbazaar/http-client. |
| apps/wallet-server/http-client-stub.cjs | Introduces Node fetch-based CJS stub for @digitalbazaar/http-client. |
| apps/wallet-server/Dockerfile | Includes the new http-client stub in the runtime image. |
| apps/wallet-server/package.json | Adds admin scripts and extends unit test command list. |
| apps/wallet-server/jest.setup.js | Adds additional mocking (but currently duplicates an existing logger mock). |
| apps/wallet-server/.env.test | Adds wallet-server test env file (currently includes a live-looking API key). |
| apps/wallet-server/test-mcp.js | Adds an ad-hoc MCP client script for local manual testing. |
| apps/wallet-server/README.md | Updates wallet server status, tools, JWT auth docs, and known limitations. |
| apps/truvera-api/src/index.ts | Adds HTTP-mode passthrough auth + per-session handler factory; keeps stdio behavior. |
| apps/truvera-api/src/build-info.ts | Bumps build metadata. |
| apps/truvera-api/README.md | Documents HTTP auth behavior (shared fallback key vs per-client headers). |
| apps/truvera-api/tests/unit/truvera-client.test.ts | Adds unit tests verifying per-session API key usage in TruveraClient. |
| apps/truvera-api/src/features/ap2/schemas/SCHEMAS.md | Updates AP2 schema documentation details/requirements. |
| apps/truvera-api/src/features/ap2/README.md | Clarifies optional subject_did behavior and adds test structure notes. |
| .gitignore | Ignores local Terraform state and overrides. |
| terraform/main.tf | Adds Terraform provider config for AWS. |
| terraform/backend.tf | Configures S3 backend for remote state. |
| terraform/variables.tf | Declares deployment variables (images, hostnames, sizing, secrets). |
| terraform/terraform.tfvars.example | Provides an example tfvars template with setup instructions. |
| terraform/vpc.tf | Provisions a VPC, IGW, public subnets, and public route table. |
| terraform/security_groups.tf | Adds ALB/task/EFS security groups with ALB-only ingress to tasks. |
| terraform/alb.tf | Configures an ALB with HTTPS listener + host-based routing to both services. |
| terraform/efs.tf | Provisions EFS + access point for wallet SQLite persistence (single-writer). |
| terraform/ecs.tf | Defines ECS cluster, task defs, services, EFS mount, and secrets injection. |
| terraform/iam.tf | Adds execution/task roles and Secrets Manager access policies. |
| terraform/logs.tf | Adds CloudWatch log groups for both services. |
| terraform/outputs.tf | Outputs core infra/service identifiers and ALB DNS/zone info. |
| terraform/environments/test.tfvars | Adds a test environment tfvars stub. |
| terraform/environments/staging.tfvars | Adds staging tfvars with concrete values. |
| terraform/environments/prod.tfvars | Adds prod environment tfvars stub. |
| terraform/.terraform.lock.hcl | Locks Terraform provider versions/hashes. |
| .github/workflows/ci.yml | Scopes API CI to path filters and simplifies the workflow graph. |
| .github/workflows/docker-publish-reusable.yml | Adds optional explicit Docker tag support. |
| .github/workflows/docker-publish-api.yml | Plumbs through optional explicit Docker tag input. |
| .github/workflows/docker-publish-wallet.yml | Plumbs through optional explicit Docker tag input. |
| .github/copilot-instructions.md | Updates repo instructions to include wallet-server and new scripts/CI pattern. |
Files not reviewed (1)
- terraform/.terraform.lock.hcl: Generated file
| # Truvera API Configuration | ||
| TRUVERA_API_KEY=eyJzY29wZXMiOlsidGVzdCIsImFsbCJdLCJzdWIiOiIyMTYyNiIsInNlbGVjdGVkVGVhbUlkIjowLCJjcmVhdG9ySWQiOiIyMyIsImZtdCI6MSwiaWF0IjoxNzczNzcwNDU2LCJleHAiOjQ4NTMwNjY0NTZ9.K9TIa50Saw4SC63PGPWVWQw21dxq06awpL6vhhqs2igmWOxiWexNxF3f8hRmebEAchEhUKeBpq07dbU2fHEZAw | ||
| TRUVERA_API_ENDPOINT=https://api-testnet.truvera.io |
| // wallet-sdk-wasm's storageService calls global.localStorage for DID resolution | ||
| // caching during BBS+ proof generation. Node.js has no native localStorage, so | ||
| // we use node-localstorage backed by the same /data volume as the wallet DB. | ||
| const WALLET_DB_PATH_RESOLVED = process.env.WALLET_DB_PATH || "/data/wallet-db"; | ||
| const _lsPath = `${WALLET_DB_PATH_RESOLVED}-localstorage`; | ||
| (globalThis as any).localStorage = new LocalStorage(_lsPath); |
| * Concurrency across processes: SQLite is single-writer. This pool assumes a | ||
| * single running process per storage volume, which is enforced by using EBS | ||
| * (single-attach) in ECS. Do not use EFS for wallet storage. |
| // The wallet SDK can emit late debug logs from background status updates. | ||
| // Keep integration tests deterministic by silencing data-store logger output. | ||
| jest.mock('@docknetwork/wallet-sdk-data-store/src/logger', () => ({ | ||
| logger: { | ||
| debug: jest.fn(), | ||
| performance: jest.fn(), | ||
| error: jest.fn(), | ||
| warn: jest.fn(), | ||
| info: jest.fn(), | ||
| }, | ||
| })); |
| Statement = [{ | ||
| Effect = "Allow" | ||
| Action = ["secretsmanager:GetSecretValue", "kms:Decrypt"] | ||
| Resource = [var.wallet_secret_arn] | ||
| }] |
| function normalizeDIDDocument(doc: any): any { | ||
| if (!doc || typeof doc !== "object") return doc; | ||
| // Clone before mutating — the resolver caches documents in node-localstorage and | ||
| // deserialises them on each cache hit, so mutating in place would cause a fresh | ||
| // copy to be processed again on the next resolution of the same DID, accumulating | ||
| // duplicate entries in verificationMethod. | ||
| const result = structuredClone(doc); | ||
| const extra: any[] = []; | ||
| for (const prop of ["assertionMethod", "authentication", "capabilityInvocation", "capabilityDelegation"]) { | ||
| if (!Array.isArray(result[prop])) continue; | ||
| result[prop] = result[prop].map((entry: any) => { | ||
| if (!Array.isArray(doc[prop])) continue; |
| // Track active MessageClients so we can stop background timers on shutdown | ||
| const activeMessageClients = new Set<MessageClient>(); | ||
|
|
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 54 out of 57 changed files in this pull request and generated 3 comments.
Files not reviewed (1)
- terraform/.terraform.lock.hcl: Generated file
Comments suppressed due to low confidence (2)
apps/wallet-server/src/index.ts:38
- normalizeDIDDocument mutates the DID document in-place (including appending to verificationMethod). If the underlying resolver caches the resolved document object, repeated resolves can accumulate duplicate verificationMethod entries and/or pollute the shared DID cache. Clone before normalizing so the cached value remains stable.
function normalizeDIDDocument(doc: any): any {
if (!doc || typeof doc !== "object") return doc;
const extra: any[] = [];
for (const prop of ["assertionMethod", "authentication", "capabilityInvocation", "capabilityDelegation"]) {
if (!Array.isArray(doc[prop])) continue;
apps/wallet-server/package.json:40
- These AWS SDK v3 devDependencies require Node.js >=20 (per package-lock), but this package declares engines node>=18. This can break installs/running admin scripts on Node 18 even though the repo docs currently say Node 18+.
"devDependencies": {
"@aws-sdk/client-secrets-manager": "^3.1082.0",
"@aws-sdk/credential-providers": "^3.1082.0",
"@babel/core": "^7.26.0",
| async function readJsonBody(req: IncomingMessage): Promise<unknown> { | ||
| const data = await new Promise<string>((resolve, reject) => { | ||
| let chunks = ""; | ||
| req.on("data", (chunk: Buffer) => { | ||
| chunks += chunk; | ||
| }); | ||
| req.on("end", () => resolve(chunks)); | ||
| req.on("error", reject); | ||
| }); | ||
| return data ? JSON.parse(data) : undefined; | ||
| } |
| } else if (!sessionId && initializeRequest) { | ||
| // New session: create transport and server instance | ||
| // New session: resolve auth then create transport and server instance | ||
| let authContext; | ||
| try { | ||
| authContext = await resolveAuthContext(req, authConfig ?? { mode: "none" }); |
| variable "aws_region" { | ||
| description = "AWS region to deploy into" | ||
| default = "us-west-1" | ||
| } |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 54 out of 57 changed files in this pull request and generated 6 comments.
Files not reviewed (1)
- terraform/.terraform.lock.hcl: Generated file
Comments suppressed due to low confidence (4)
apps/wallet-server/src/tests/integration/localstorage-shim.test.ts:48
- Same as above: prefer importing from the package’s built
lib/path in tests to match runtime usage and avoid relying on unpublished/non-exportedsrc/internals.
const { storageService } = await import("@docknetwork/wallet-sdk-wasm/src/services/storage/service");
const payload = JSON.stringify({ did: "did:example:123", timestamp: 1000000 });
apps/wallet-server/src/tests/integration/localstorage-shim.test.ts:56
- Same issue here: this deep import targets
src/internals; switching back to thelib/build output keeps the test aligned with how the app imports wallet-sdk-wasm.
const { storageService } = await import("@docknetwork/wallet-sdk-wasm/src/services/storage/service");
await storageService.setItem("did-cache:did:example:remove", "value");
apps/wallet-server/src/tests/integration/localstorage-shim.test.ts:64
- Same issue here: importing
@docknetwork/wallet-sdk-wasm/src/...is brittle across package versions; prefer the publishedlib/...path used by the application code.
const { storageService } = await import("@docknetwork/wallet-sdk-wasm/src/services/storage/service");
const key = "did-cache:did:example:persist-check";
apps/wallet-server/package.json:39
@aws-sdk/client-secrets-manager/@aws-sdk/credential-providersversions added here require Node.js >=20 (see theirenginesin package-lock), but this package still declaresengines.node >=18. Either bump the engine requirement or pin AWS SDK versions compatible with Node 18 to avoid misleading consumers and potential install/runtime issues.
"@aws-sdk/client-secrets-manager": "^3.1082.0",
"@aws-sdk/credential-providers": "^3.1082.0",
| # Remote state stored in S3 within each environment's own AWS account. | ||
| # The bucket name is the same across accounts — AWS credentials (via | ||
| # AWS_PROFILE) determine which account's bucket is used. | ||
| # | ||
| # Create the bucket and lock table ONCE per account before running | ||
| # terraform init (Terraform can't bootstrap its own state bucket): |
| # WALLET_MASTER_KEY is the HMAC root key for all tenant wallet derivation. | ||
| # Rotating it invalidates every existing wallet. Treat it like a master password. |
| const { storageService } = await import("@docknetwork/wallet-sdk-wasm/src/services/storage/service"); | ||
| const result = storageService.getItem("missing-key"); | ||
| expect(result).toBeNull(); |
| | Variable | Required | Default | Description | | ||
| |----------|----------|---------|-------------| | ||
| | `WALLET_MASTER_KEY` | Yes | — | Master encryption key for the wallet | | ||
| | `WALLET_MASTER_KEY` | Yes | — | Reserved for a future wallet encryption key. Currently checked at startup (a warning is logged if unset) but not yet wired into the data store — see [Known limitations](#known-limitations). | |
|
|
||
| const walletClient = await walletPool.get(dbPath, CHEQD_NETWORK); | ||
| const wallet = walletClient.getWallet(); |
| adminRevoke: | ||
| MCP_AUTH_MODE === "jwt" && ADMIN_REVOKE_SECRET | ||
| ? { secret: ADMIN_REVOKE_SECRET, onRevoke: (tenantId) => revocationStore!.revoke(tenantId) } | ||
| : undefined, |
** Uses the user's Truvera API key for the API server
** Uses a generated wallet token for the wallet server (generated by admin script) - NOTE: doesn't support revocation yet